Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | /** * API route for paginated session history with cursor-based pagination * * GET /api/curriculum/[playerId]/sessions/history * Query params: * - cursor: Session ID to start after (optional, for pagination) * - limit: Number of sessions to return (default: 20, max: 100) * * Response: * { * sessions: PracticeSession[], * nextCursor: string | null, // ID of last session, null if no more * hasMore: boolean * } */ import { NextResponse } from 'next/server' import { and, desc, eq, lt, ne } from 'drizzle-orm' import { db } from '@/db' import { sessionPlans } from '@/db/schema/session-plans' import { withAuth } from '@/lib/auth/withAuth' import { canPerformAction } from '@/lib/classroom' import { getUserId } from '@/lib/viewer' export const GET = withAuth(async (request, { params }) => { const routeStart = performance.now() const timings: Record<string, number> = {} try { const { playerId } = (await params) as { playerId: string } const { searchParams } = new URL(request.url) const cursor = searchParams.get('cursor') const limitParam = searchParams.get('limit') const limit = Math.min(Math.max(parseInt(limitParam ?? '20', 10) || 20, 1), 100) // Soft-deleted sessions (#158) are hidden by default. This is the ONE // status reader that filters on `completedAt` rather than a positive // status allow-list, so it needs an explicit `status != 'deleted'` guard. // `?includeDeleted=1` flips it to return ONLY deleted rows, powering the // collapsed "N removed sessions" reveal with Restore. const includeDeleted = searchParams.get('includeDeleted') === '1' if (!playerId) { return NextResponse.json({ error: 'Player ID required' }, { status: 400 }) } // Authorization check let t = performance.now() const userId = await getUserId() const canView = await canPerformAction(userId, playerId, 'view') timings.auth = performance.now() - t if (!canView) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } // Build query conditions const conditions = [ eq(sessionPlans.playerId, playerId), // Only include completed sessions // completedAt is not null check done via ordering includeDeleted ? eq(sessionPlans.status, 'deleted') : ne(sessionPlans.status, 'deleted'), ] // If cursor provided, get sessions older than the cursor session if (cursor) { t = performance.now() // Get the cursor session's completedAt to paginate from there const cursorSession = await db.query.sessionPlans.findFirst({ where: eq(sessionPlans.id, cursor), columns: { completedAt: true }, }) timings.cursorLookup = performance.now() - t if (cursorSession?.completedAt) { conditions.push(lt(sessionPlans.completedAt, cursorSession.completedAt)) } } // Fetch limit + 1 to check if there are more t = performance.now() const sessions = await db.query.sessionPlans.findMany({ where: and(...conditions), orderBy: [desc(sessionPlans.completedAt)], limit: limit + 1, }) timings.dbQuery = performance.now() - t // Filter to only completed sessions and check for more t = performance.now() const completedSessions = sessions.filter((s) => s.completedAt !== null) const hasMore = completedSessions.length > limit const returnSessions = completedSessions.slice(0, limit) // Transform to match PracticeSession interface expected by client const transformedSessions = returnSessions.map((session) => { const results = session.results return { id: session.id, playerId: session.playerId, startedAt: session.startedAt, completedAt: session.completedAt, // Present only on the includeDeleted view; powers the Restore affordance. deletedAt: session.deletedAt, problemsAttempted: results.length, problemsCorrect: results.filter((r) => r.isCorrect).length, totalTimeMs: results.reduce((sum, r) => sum + (r.responseTimeMs ?? 0), 0), skillsUsed: [...new Set(results.flatMap((r) => r.skillsExercised ?? []))], } }) timings.transform = performance.now() - t const total = performance.now() - routeStart console.log( `[PERF] /api/curriculum/.../sessions/history: ${total.toFixed(1)}ms | ` + `auth=${timings.auth.toFixed(1)}ms, ` + `db=${timings.dbQuery.toFixed(1)}ms, ` + `transform=${timings.transform.toFixed(1)}ms | ` + `sessions=${returnSessions.length}` ) return NextResponse.json({ sessions: transformedSessions, nextCursor: hasMore ? returnSessions[returnSessions.length - 1]?.id : null, hasMore, }) } catch (error) { console.error('Error fetching session history:', error) return NextResponse.json({ error: 'Failed to fetch session history' }, { status: 500 }) } }) |